今天我們要對我們的網路商店加入狀態管理。我們這次使用recoil來進行管理。
pnpm add recoil
為了確保整個應用程序都可以訪問Recoil的狀態,我們在主要的provider中添加了RecoilRoot
。打開我們的provider,並在apps\iron-ecommerce-next\app\app-provider.tsx
加入RecoilRoot
"use client";
import { CSSProvider } from "@master/css.react";
import { Theme } from "@radix-ui/themes";
import config from "master.css";
import { RecoilRoot } from "recoil";
export default function AppProvider({ children }: { children: React.ReactNode }) {
return (
<RecoilRoot>
<Theme>
<CSSProvider config={config}>
{children}
</CSSProvider>
</Theme>
</RecoilRoot>
);
}
接著我們創建store資料夾,並將狀態放入:
首先我們創建了一系列的schemas,這些schemas定義了購物車、商品和用戶的資料結構:
////// apps\iron-ecommerce-next\store\schemas\cart.schema.ts
import { z } from "zod";
export const cartItemSchema = z.object({
productId: z.string(),
productName: z.string(),
price: z.number().nonnegative(),
quantity: z.number().nonnegative()
});
export const cartSchema = z.array(cartItemSchema);
export type CartItem = z.infer<typeof cartItemSchema>;
export type Cart = z.infer<typeof cartSchema>;
////// apps\iron-ecommerce-next\store\schemas\product.schema.ts
import { z } from "zod";
export const productSchema = z.object({
id: z.string(),
name: z.string(),
price: z.number().nonnegative(),
description: z.string(),
imageUrl: z.string()
});
export const productsSchema = z.array(productSchema);
export type Product = z.infer<typeof productSchema>;
export type Products = z.infer<typeof productsSchema>;
///// apps\iron-ecommerce-next\store\schemas\user.schema.ts
import { z } from "zod";
export const userSchema = z.union([
z.null(),
z.object({
id: z.string(),
name: z.string(),
email: z.string().email()
})
]);
export type User = z.infer<typeof userSchema>;
接著創建state:
///// apps\iron-ecommerce-next\store\state\cart.state.tsx
import { atom } from "recoil";
import { Cart, cartSchema } from "../schemas/cart.schema";
export const cartState = atom<Cart>({
key: "cartState",
default: cartSchema.safeParse([]).success ? [] : undefined
});
///// apps\iron-ecommerce-next\store\state\product.state.tsx
import { atom } from "recoil";
import { Products, productsSchema } from "../schemas/product.schema";
export const productsState = atom<Products>({
key: "productsState",
default: productsSchema.safeParse([]).success ? [] : undefined
});
///// apps\iron-ecommerce-next\store\state\user.state.tsx
import { atom } from "recoil";
import { User, userSchema } from "../schemas/user.schema";
export const userState = atom<User>({
key: "userState",
default: userSchema.safeParse(null).success ? null : undefined
});
我們需要一些操作來修改這些狀態。為此,我們創建了actions,這些actions提供了一個界面來與我們的狀態互動:
///// apps\iron-ecommerce-next\store\actions\cart.actions.tsx
import { selector, useRecoilState, useRecoilValue } from "recoil";
import { CartItem } from "../schemas/cart.schema";
import { cartState } from "../state/cart.state";
const cartTotalPrice = selector({
key: "cartTotalPrice",
get: ({ get }) => {
const cart = get(cartState);
return cart.reduce((total, item) => {
return total + item.price * item.quantity;
}, 0);
}
});
export const useCartActions = () => {
const [cart, setCart] = useRecoilState(cartState);
const addToCart = (item: CartItem) => {
const newCart = [...cart, item];
setCart(newCart);
};
const removeFromCart = (productId: string) => {
const newCart = cart.filter((item) => item.productId !== productId);
setCart(newCart);
};
const updateCartItem = (productId: string, quantity: number) => {
const newCart = cart.map((item) => (item.productId === productId ? { ...item, quantity } : item));
setCart(newCart);
};
const checkout = () => {
setCart([]);
};
const totalPrice = useRecoilValue(cartTotalPrice);
return {
addToCart,
removeFromCart,
updateCartItem,
checkout,
cart,
totalPrice
};
};
///// apps\iron-ecommerce-next\store\actions\product.actions.tsx
import { useRecoilState } from "recoil";
import { Products } from "../schemas/product.schema";
import { productsState } from "../state/product.state";
export const useProductActions = () => {
const [products, setProducts] = useRecoilState(productsState);
const updateProducts = (newProducts: Products) => {
setProducts(newProducts);
};
return {
updateProducts,
products
};
};
///// apps\iron-ecommerce-next\store\actions\user.actions.tsx
import { useRecoilState } from "recoil";
import { User } from "../schemas/user.schema";
import { userState } from "../state/user.state";
export const useUserActions = () => {
const [user, setUser] = useRecoilState(userState);
const login = (userData: User) => {
setUser(userData);
};
const logout = () => {
setUser(null);
};
return {
login,
logout,
user
};
};
接下來先在product page加入狀態來測試,apps\iron-ecommerce-next\app\products\products.client.tsx
:
"use client";
import { Flex } from "@radix-ui/themes";
import { Product } from "../../store/schemas/product.schema";
import ProductCard from "libs/iron-components/src/lib/ProductCard";
interface ProductsProps {
products: Product[];
}
const ProductsClient = ({ products }: ProductsProps) => {
return (
<Flex align="center" justify="center">
<section className="w:60% p:1rem flex flex:wrap flex-direction:row gap:1rem jc:center">
{products.map((product, i) => (
<ProductCard
key={product.id}
title={product.name}
price={`$${product.price.toFixed(2)}`}
description={product.description}
width="30%"
imageUrl={product.imageUrl}
/>
))}
</section>
</Flex>
);
};
export default ProductsClient;
apps\iron-ecommerce-next\app\products\page.tsx
import { NextPage } from "next";
import { productsSchema } from "../../store/schemas/product.schema";
import ProductsClient from "./products.client";
const fakeProducts = Array.from({ length: 30 }).map((_, i) => ({
id: (i + 1).toString(),
name: `Sample Product ${i + 1}`,
price: 100.0,
description: "This is a description for the sample product.",
imageUrl: "https://www.w3schools.com/tags/img_girl.jpg"
}));
const getData = async () => {
const result = productsSchema.safeParse(fakeProducts);
const products = result.success ? result.data : [];
return { products };
};
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface ProductsPageProps {}
const ProductsPage: NextPage<ProductsPageProps> = async () => {
const { products } = await getData();
return <ProductsClient products={products} />;
};
export default ProductsPage;
apps\iron-ecommerce-next\app\cart\cart.client.tsx
:
"use client";
import { Card, Flex } from "@radix-ui/themes";
import CartForm from "libs/iron-components/src/lib/CartForm";
import { useCartActions } from "../../store/actions/cart.actions";
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface CartProps {}
// eslint-disable-next-line no-empty-pattern
const CartClient = ({}: CartProps) => {
const { cart } = useCartActions();
const cartProducts = cart.map((item) => ({
id: item.productId,
name: item.productName,
quantity: item.quantity,
price: item.price
}));
return (
<Flex align="center" justify="center">
<section className="w:60% p:1rem flex flex:wrap flex-direction:row gap:1rem jc:center">
<Card>
<CartForm items={cartProducts} />
</Card>
</section>
</Flex>
);
};
export default CartClient;
並且修正一下我們的元件Header
、CartForm
以及ProductCard
Header:
// libs\iron-components\src\lib\Header\Header.tsx
import { Button, Flex, Heading, Popover, Text } from "@radix-ui/themes";
import Link from "next/link";
import React from "react";
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface HeaderProps {
cart: {
productId: string;
productName: string;
quantity: number;
price: number;
}[];
onRemoveFromCart?: (productId: string) => void;
}
const Header: React.FC<HeaderProps> = ({cart, onRemoveFromCart}) => {
const handleRemoveFromCart = (productId: string) => {
if (onRemoveFromCart) {
onRemoveFromCart(productId);
}
}
return (
<header className="flex flex-direction:row jc:space-between ai:center p:1em bg:#333 color:#fff">
<div className="flex-shrink:0 f:1.5rem f:bold">Iron Shop</div>
<nav className="flex-grow:1 flex m:0|1em">
<ul className="list-style:none flex gap:1rem m:0 p:0 {cursor:pointer}>li">
<li>
<Link href="/" className="text-decoration:none color:#fff">
Home
</Link>
</li>
<li>
<Link href="/products" className="text-decoration:none color:#fff">
Products
</Link>
</li>
</ul>
</nav>
<div className="flex gap:1rem">
<Popover.Root>
<Popover.Trigger>
<Text className="color:#fff cursor:pointer">Cart</Text>
</Popover.Trigger>
<Popover.Content style={{ width: 360 }}>
<Heading size="2" mb="1">
Cart Items
</Heading>
<Flex direction="column" align="stretch">
{
cart.map((item) => (
<Flex key={item.productId} align="center" gap="1" style={{ padding: "0.5rem" }}>
<Text size="3" weight="bold">{item.productName}</Text>
<Text size="2" color="gray">{`${item.price}$ x ${item.quantity}`}</Text>
<Button size="1" variant="soft" onClick={() => handleRemoveFromCart(item.productId)}>
Remove
</Button>
</Flex>
))
}
<Link href="/cart">
<Button size="1" variant="soft">
Go to Cart
</Button>
</Link>
</Flex>
</Popover.Content>
</Popover.Root>
<Link href="/user/tester" className="text-decoration:none color:#fff">
User
</Link>
</div>
</header>
);
};
export default Header;
ProductCard:
// libs\iron-components\src\lib\ProductCard\ProductCard.tsx
import { Box, Button, Card, Flex, Text } from "@radix-ui/themes";
import React from "react";
interface ProductCardProps {
id?: string;
title?: string;
description?: string;
price?: string;
imageUrl?: string;
width?: string;
onAddToCart?: (args: { productId: string; quantity: number }) => void;
}
const ProductCard: React.FC<ProductCardProps> = (props) => {
const { id, title, description, price, imageUrl, width = "auto", onAddToCart } = props;
const handleAddToCart = () => {
if (onAddToCart) {
onAddToCart({ productId: id ?? "-1", quantity: 1 });
}
};
return (
<Card className={`w:${width}`}>
<Box>
<picture>
<img src={imageUrl} aria-hidden alt="Sample Image" width="100%" />
</picture>
<Flex>
<Text as="div" size="2" weight="bold">
{title}
</Text>
<Button size="1" variant="soft" onClick={handleAddToCart}>Add</Button>
</Flex>
{price && (
<Text as="div" size="2" color="gray">
{price}
</Text>
)}
{description && (
<Text as="div" size="2" color="gray">
{description}
</Text>
)}
</Box>
</Card>
);
};
export default ProductCard;
CartForm
// libs\iron-components\src\lib\CartForm\CartForm.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { Button, Table, TextField } from "@radix-ui/themes";
import React from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
interface Product {
id: string;
name: string;
price: number;
}
interface CartItem extends Product {
quantity: number;
}
type CartFormValues = Record<string, { quantity: string }>;
interface CartFormProps {
items: CartItem[];
}
const CartItemSchema = z.object({
quantity: z.string().regex(/^\d+$/, "必須是有效的正整數")
});
const generateCartSchema = (items: CartItem[]) => {
const schemaShape: Record<string, typeof CartItemSchema> = {};
items.forEach((item) => {
schemaShape[item.id] = CartItemSchema;
});
return z.object(schemaShape);
};
const CartForm: React.FC<CartFormProps> = ({ items }) => {
const cartSchema = generateCartSchema(items);
const {
register,
handleSubmit,
watch,
formState: { errors }
} = useForm<CartFormValues>({
resolver: zodResolver(cartSchema)
});
const watchedValues = watch();
const calculateItemTotal = (id: string, price: number) => {
const quantity = parseInt(watchedValues[id]?.quantity || "0", 10);
return quantity * price;
};
const grandTotal = items.reduce((acc, item) => acc + calculateItemTotal(item.id, item.price), 0);
const onSubmit = (data: CartFormValues) => {
console.log("Form data:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>商品名稱</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>價格</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>數量</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>總計</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row key={item.id}>
<Table.RowHeaderCell>{item.name}</Table.RowHeaderCell>
<Table.Cell>{item.price} 元</Table.Cell>
<Table.Cell>
<TextField.Input
{...register(`${item.id}.quantity`)}
defaultValue={item.quantity.toString()}
type="number"
/>
{typeof errors[item.id]?.quantity?.message === "string" && (
<span style={{ color: "red" }}>{errors[item.id]?.quantity?.message}</span>
)}
</Table.Cell>
<Table.Cell>{calculateItemTotal(item.id, item.price)} 元</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
<div className="flex jc:space-between ai:center mt:5em">
<span>總計:{grandTotal} 元</span>
<Button type="submit">更新購物車</Button>
</div>
</form>
);
};
export default CartForm;
接下來修正一下我們的header,打開apps\iron-ecommerce-next\app\template.tsx
:
"use client";
import { Flex } from "@radix-ui/themes";
import Header from "libs/iron-components/src/lib/Header";
import { useCartActions } from "../store/actions/cart.actions";
export default function Template({ children }: { children: React.ReactNode }) {
const { cart, removeFromCart } = useCartActions();
return (
<Flex direction="column">
<Header cart={cart} onRemoveFromCart={removeFromCart} />
{children}
</Flex>
);
}
透過本文,我們介紹了如何使用Recoil進行狀態管理。從創建數據schemas,到狀態的組織和管理,再到如何在實際組件中應用這些狀態和操作。